Skip navigation
NintendoAge
Welcome, Guest! Please Login or Join
Loading...

## Nerdy Nights Sound: Part 8 Opcodes and Looping

- Posts 1 - 6 of 6
- 1

Nov 10, 2009 at 7:13:52 AM
MetalSlime (0)
(Thomas Hjelm) < Crack Trooper >
Posts: 140 - Joined: 08/14/2008
Japan
Profile
Last Week: Volume Envelopes
This Week: Opcodes and Looping
Opcodes
So far our sound engine handles two type of data that it reads from music data streams: notes and note lengths. This is enough to write complex music but of course we are going to want more features. We will want control over the sound of our notes. What if we want to change duty cycles midstream? Or volume envelopes? Or keys? What if we want to loop one part of the song four times? Or loop the entire song continuously? What if we want to play a sound effect as part of a song?
All of these types of features, features where you are issuing commands to the engine, are going to be done through opcodes (also called control codes or command codes). An opcode is a value in the data stream that tells the engine to run a specific, specialized subroutine or piece of code. Most opcodes will have arguments sent along with them. For example, an opcode that changes a stream's volume envelope will come with an argument that specifies which volume envelope to change to.
We've actually been using an opcode for weeks, I just haven't mentioned it. It's the opcode that ends a sound, and we've been encoding it in our data streams as $FF. Here is the code we've been using:
se_fetch_byte:
;---snip--- (fetch a byte and range test)
.opcode: ;else it's an opcode
;do Opcode stuff
cmp #$FF
bne .end
lda stream_status, x ;if $FF, end of stream, so disable it and silence
and #%11111110
sta stream_status, x ;clear enable flag in status byte
lda stream_channel, x
cmp #TRIANGLE
beq .silence_tri ;triangle is silenced differently from squares and noise
lda #$30 ;squares and noise silenced with #$30
bne .silence
.silence_tri:
lda #$80 ;triangle silenced with #$80
.silence:
sta stream_vol_duty, x ;store silence value in the stream's volume variable.
jmp .update_pointer ;done
;---snip--- (do note lengths and notes, update the stream's pointer)
rts
Here we check if the byte read has a value of $FF. If so we turn the stream off and silence it. That's an opcode.
It would be pretty messy if every opcode we had was just written straight out like this. Normally we would pull this code into its own subroutine, like this:
se_fetch_byte:
;---snip--- (fetch a byte and range test)
.opcode: ;else it's an opcode
;do Opcode stuff
cmp #$FF ;end sound opcode
bne .end
jsr se_op_endsound ;call the endsound subroutine
iny
jmp .fetch ;grab the next byte in the stream.
;---snip--- (do note lengths and notes, update the stream's pointer)
rts
se_op_endsound:
lda stream_status, x ;end of stream, so disable it and silence
and #%11111110
sta stream_status, x ;clear enable flag in status byte
lda stream_channel, x
cmp #TRIANGLE
beq .silence_tri ;triangle is silenced differently from squares and noise
lda #$30 ;squares and noise silenced with #$30
bne .silence
.silence_tri:
lda #$80 ;triangle silenced with #$80
.silence:
sta stream_vol_duty, x ;store silence value in the stream's volume variable.
rts
The .opcode branch is much shorter now. If we wanted to add more opcodes, we could just add some more compares:
.opcode:
;do Opcode stuff
cmp #$FF ;is it the end sound opcode?
bne .not_FF
jsr se_op_endsound ;if so, call the end sound subroutine
jmp .end ;and finish
.not_FF:
cmp #$FE ;else is it the loop opcode?
bne .not_FE
jsr se_op_loop ;if so, call the loop subroutine
jmp .opcode_done
.not_FE:
cmp #$FD ;else is it the change volume envelope opcode?
bne .not_FD
jsr se_op_change_ve ;if so, call the change volume envelope subroutine
jmp .opcode_done
.not_FD:
.opcode_done:
iny ;update index to next byte in the data stream
jmp .fetch ;go fetch another byte
This will work, but it's ugly. The more opcodes we add to our engine, the more checks we need to make. What if we have 20 opcodes? Do we really want to do that many compares? It's a waste of ROM space and cycles.
Tables
Anytime you find yourself in a situation where you are doing a lot of CMPs on one value, the answer is to use a lookup table. It will simplify everything! We've done it already with notes, note lengths, song numbers and volume envelopes. Could you imagine trying to get a note's period without using the lookup table? It would look like this:
Is the note an A1? If so, use this period, else
Is the note an A#1? If so, use this period, else
Is the note a B1? If so, use this period, else
Is the note a C2? If so, use this period, else
... (about 100 more checks)
Is the note an F#9? If so, use this period, else
Is the note a rest? If so, use this period
That's just crazy. It would be hundreds of lines of unreadable code and you'd run into branch-range errors too. When we use a lookup table, the code is simplified to this:
.note:
;do Note stuff
sty sound_temp1 ;save our index
asl a
tay
lda note_table, y
sta stream_note_LO, x
lda note_table+1, y
sta stream_note_HI, x
ldy sound_temp1 ;restore data stream index
Much cleaner. Again, I can't stress it enough: if you find yourself doing lots of CMPs on a single value, use a table instead!
With notes and note lengths we used a straight lookup table of values. With song numbers and volume envelopes we used a special type of lookup table called a pointer table, which stored data addresses. For opcodes we have two choices. We can use something called a jump table or we can use an RTS table. They are almost the same and the difference in performance between the two methods is negligible so for most programmers it's a matter of personal preference.
I prefer RTS tables myself, but we're going to use jump tables because they are easier to explain and understand.
Jump Tables
Ok, here's our problem: Our sound engine has opcodes. A lot of them, let's say 10 or more. Each opcode has its own subroutine. When our sound engine reads an opcode byte from the data stream, we want to avoid a long list of CMP and BNE instructions to select the right subroutine. How do we do that? We use a jump table.
A jump table is similar to a pointer table: it is a table of addresses. But whereas a pointer table holds addresses that point to the start of data, a jump table holds addresses that point to the start of code (ie, the start of subroutines). For example, suppose we have some subroutines:
sub_a:
lda #$00
ldx #$FF
rts
sub_b:
clc
adc #$03
rts
sub_c:
sec
sbc #$03
rts
Here is how a jump table would look using these subroutines:
sub_jump_table:
.word sub_a, sub_b, sub_c
Hey, that's pretty easy. We just use the subroutine label and the assembler will translate that into the address where the subroutine starts. Let's make a jump table for our sound opcode subroutines:
se_op_endsound:
;do stuff
rts
se_op_infinite_loop:
;do stuff
rts
se_op_change_ve:
;do stuff
rts
;etc.. more subroutines
;this is our jump table
sound_opcodes:
.word se_op_endsound
.word se_op_infinite_loop
.word se_op_change_ve
;etc, one entry per subroutine
Cool. We have a jump table now. So how do we use it?
Indirect Jumping
The 6502 let's us do some cool things. One of those things is called an indirect jump. An indirect jump let's you stick a destination address into a zero-page pointer variable and jump there. It works like this:
.rsset $0000
;first declare a pointer variable somewhere in the zero-page
jmp_ptr .rs 2 ;2 bytes because an address is always a word
lda #$00
sta jmp_ptr
lda #$80
sta jmp_ptr+1
jmp [jmp_ptr] ;will jump to $8000
Here we stick an address ($8000, lo byte first) into our jmp_ptr variable. Then we do an indirect jump by using the JMP instruction followed by a pointer variable in brackets:
jmp [jmp_ptr] ;indirect jump
This instruction translates into English as "Jump to the address that is stored in jmp_ptr and jmp_ptr+1". It's extrememly useful. We can stick any address we want in there:
lda #$00
sta jmp_ptr
lda #$C0
sta jmp_ptr+1
jmp [jmp_ptr] ;will jump to $C000
We could read an address from ROM and use that if we wanted to, for example our reset vector:
lda $FFFC
sta jmp_ptr
lda $FFFD
sta jmp_ptr+1
jmp [jmp_ptr] ;will jump to our reset routine
And we can use it in combination with our jump table:
lda sound_opcodes, y ;read low byte of address from jump table
sta jmp_ptr
lda sound_opcodes+1, y ;read high byte
sta jmp_ptr+1
jmp [jmp_ptr] ;will jump to whatever address we pulled from the table.
Pretty powerful. We can dynamically jump to any section of code we want!
Implementation
So we know how to build a jump table and we know how to do an indirect jump. Let's tie it all together and stick it into our sound engine. Let's start with se_fetch_byte. se_fetch_byte reads a byte from the data stream and range-checks it to see if it is a note, note length or opcode. Recall that notes have a byte range of $00-$7F. Note lengths have a range of $80-$9F. The opcode byte range is $A0-$FF:
se_fetch_byte:
lda stream_ptr_LO, x
sta sound_ptr
lda stream_ptr_HI, x
sta sound_ptr+1
ldy #$00
lda [sound_ptr], y
bpl .note ;if < #$80, it's a Note
cmp #$A0
bcc .note_length ;else if < #$A0, it's a Note Length
.opcode: ;else ($A0-$FF) it's an opcode
;do Opcode stuff
.note_length:
;do note length stuff
.note:
;do note stuff
So we need to assign our opcodes to values between $A0 and $FF. Just as with notes and note lengths, the opcode byte we read from the data stream will be used as a table index (after subtracting $A0), so we will assign our opcodes in the same order as our table:
sound_opcodes:
.word se_op_endsound ;this should be $A0
.word se_op_infinite_loop ;this should be $A1
.word se_op_change_ve ;this should be $A2
;etc, 1 entry per subroutine
;these are aliases to use in the sound data.
endsound = $A0
loop = $A1 ;be careful of conflicts here. this might be too generic. maybe song_loop is better
volume_envelope = $A2
Now let's alter se_fetch_byte to take care of our opcodes:
se_fetch_byte:
lda stream_ptr_LO, x
sta sound_ptr
lda stream_ptr_HI, x
sta sound_ptr+1
ldy #$00
.fetch:
lda [sound_ptr], y
bpl .note ;if < #$80, it's a Note
cmp #$A0
bcc .note_length ;else if < #$A0, it's a Note Length
.opcode: ;else ($A0-$FF) it's an opcode
;do Opcode stuff
jsr se_opcode_launcher ;launch our opcode!!!
iny ;next position in the data stream
lda stream_status, x
and #%00000001
bne .fetch ;after our opcode is done, grab another byte unless the stream is disabled
rts ; in which case we quit (explained below)
.note_length:
;do note length stuff
.note:
;do note stuff
I added a call to a subroutine called se_opcode_launcher and a little branch. Not a big change is it? But there's an important detail here. se_opcode_launcher will be a short, simple subroutine that will read from the jump table and perform an indirect jump. It looks like this:
se_opcode_launcher:
sty sound_temp1 ;save y register, because we are about to destroy it
sec
sbc #$A0 ;turn our opcode byte into a table index by subtracting $A0
; $A0->$00, $A1->$01, $A2->$02, etc. Tables index from $00.
asl a ;multiply by 2 because we index into a table of addresses (words)
tay
lda sound_opcodes, y ;get low byte of subroutine address
sta jmp_ptr
lda sound_opcodes+1, y ;get high byte
sta jmp_ptr+1
ldy sound_temp1 ;restore our y register
iny ;set to next position in data stream (assume an argument)
jmp [jmp_ptr] ;indirect jump to our opcode subroutine
Short and simple. So why did I wrap this code in its own subroutine? Why not just stick this code as-is in the .opcode branch of se_fetch_byte? Because we need a place to return to.
The JSR and RTS instructions work as a pair. They go hand in hand. They need each other. Without going into too much detail, this is what goes on behind the scenes:
JSR sticks a return address on the stack and jumps to a subroutine. One way to look at it is to think of JSR as a JMP that remembers where it started from.
RTS pops the return address off the stack and jumps there.
So JSR leaves a treasure map for RTS to pick up and follow later. The key point here is that RTS expects a return address to be waiting for it on the stack.
Now our opcode subroutines all end in an RTS instruction. Do you see the potential problem here?
We call our opcode subroutines using an indirect jump. This requires us to use a JMP instruction, not a JSR instruction. A JMP instruction doesn't remember where it started from. No return address is pushed onto the stack with a JMP instruction. So when we jump to our opcode subroutine and hit the RTS instruction at the end, there is no return address waiting for us! The RTS will pull whatever random values happen to be on the stack at the time and jump there. We'll end up somewhere random and our program will surely crash!
To fix this, we wrap our indirect jump in a subroutine, se_opcode_launcher. We call it with a JSR instruction, completing the JSR/RTS pair:
jsr se_opcode_launcher ;this jsr will let us remember where we came from
This JSR instruction will stick a return address on the stack for us. Then inside se_opcode_launcher we perform our indirect jump to our desired opcode subroutine. Now when we hit that RTS instruction at the end of the opcode subroutine we have a return address waiting for us on the stack. Our program returns back to where we started. We are safe.
Opcode Subroutines
With our opcode launcher written, we are all set up to make opcodes. We already have one written: the endsound opcode. This is the opcode we will use to terminate sound effects. Sound effects don't loop continuously like songs do, so they need to be stopped. Let's take a look again:
se_op_endsound:
lda stream_status, x ;end of stream, so disable it and silence
and #%11111110
sta stream_status, x ;clear enable flag in status byte
lda stream_channel, x
cmp #TRIANGLE
beq .silence_tri ;triangle is silenced differently from squares and noise
lda #$30 ;squares and noise silenced with #$30
bne .silence ; (this will always branch. bne is cheaper than a jmp)
.silence_tri:
lda #$80 ;triangle silenced with #$80
.silence:
sta stream_vol_duty, x ;store silence value in the stream's volume variable.
rts
This opcode is special. It's the reason for the check after the call to se_opcode_launcher:
se_fetch_byte:
;---snip---
.opcode: ;else ($A0-$FF) it's an opcode
;do Opcode stuff
jsr se_opcode_launcher
iny ;next position in the data stream
lda stream_status, x
and #%00000001
bne .fetch ;after our opcode is done, grab another byte unless the stream is disabled
rts ; in which case we quit (explained below)
;---snip---
Normally, we want se_fetch_byte to keep fetching bytes until it hits a note. Recall that with note lengths we jumped back to .fetch after setting the new note length. This is because after setting the length of the note, we needed to know WHAT note to play. So we fetch another byte. The same thing is true of opcodes. If we change the volume envelope with an opcode, great! But we still need to know what note to play next. If we use an opcode to switch our square's duty cycle, great! But we still need to know what note to play next. If we use an opcode to loop back to the beginning of the song, that's great! But we still need to read that first note of the song. This is why we jump back to fetch a byte after we run an opcode.
The ONE exception to this rule is when we end a sound effect. We are terminating the sound effect completely, so there is no next note. We don't want to fetch something that isn't there, so we need to skip the jump. That's why we check the status byte after we run the opcode. If the stream is disabled by the endsound opcode, we are finished. Otherwise, fetch another byte.
Looping
The next opcode in our list is the loop opcode. This is the opcode that we will stick at the end of every song to tell the sound engine to play the song again, and again and again. It is actually quite easy to implement. It takes a 2-byte argument, which is the address to loop back to. The subroutine looks like this:
se_op_infinite_loop:
lda [sound_ptr], y ;read LO byte of the address argument from the data stream
sta stream_ptr_LO, x ;save as our new data stream position
iny
lda [sound_ptr], y ;read HI byte of the address argument from the data stream
sta stream_ptr_HI, x ;save as our new data stream position data stream position
sta sound_ptr+1 ;update the pointer to reflect the new position.
lda stream_ptr_LO, x
sta sound_ptr
ldy #$FF ;after opcodes return, we do an iny. Since we reset
;the stream buffer position, we will want y to start out at 0 again.
rts
The first thing to notice about this subroutine is that it reads two bytes from the data stream. This is the address argument that gets passed along with the opcode. To make it clear, let's look at some example sound data:
song1_square1:
.byte eighth ;set note length to eighth notes
.byte C5, E5, G5, C6, E6, G6, C5, Eb5, G5, C6, Eb6, half, G6 ;play some notes
.byte loop ;this alias evaluates to $A1, the loop opcode
.word song1_square1 ;this evaluates to the address of the song1_square1 label
;ie, the address we want to loop to.
After the "loop" opcode comes a word which is the address to loop back to. In this example I chose to loop back to the beginning of the stream data.
So what does our loop opcode do? It reads the first byte of this address argument (the low byte) and stores it in stream_ptr_LO. Then it reads the second byte of the address argument (the high byte) and stores it in stream_ptr_HI. These are the variables that keep track of our data stream position! The loop opcode just changes these values to some address that we specify. Not too complicated at all. The last step is to update the actual pointer (sound_ptr) so that the next byte we read from the data stream will be the first note we looped back to.
In the example sound data above I looped back to the beginning of the stream data, but there's nothing stopping me from looping somewhere else:
song1_square1:
;intro, don't loop this part
.byte quarter
.byte C4, C4, C4, C4
.loop_point: ;this is where we will loop back to.
.byte eighth ;set note length to eighth notes
.byte C5, E5, G5, C6, E6, G6, C5, Eb5, G5, C6, Eb6, half, G6
.byte loop ;this alias evaluates to $A1, the loop opcode
.word .loop_point ;this evaluates to the address of the .loop_point label
;ie, the address we want to loop to.
Technically we can also "loop" to a forward position, in which case it's actually more like a jump than a loop. That's all a loop is really: a jump... backwards.
Changing Volume Envelopes
Let's write the opcode subroutine to change volume envelopes. This one is even easier. It takes one argument, which will be which volume envelope to switch to:
se_op_change_ve:
lda [sound_ptr], y ;read the argument
sta stream_ve, x ;store it in our volume envelope variable
lda #$00
sta stream_ve_index, x ;reset volume envelope index to the beginning
rts
That's it!
Changing Duty Cycles
Now let's add an opcode that will change the duty cycle for a square stream. This one also takes one argument: which duty cycle to switch to.
se_op_duty:
lda [sound_ptr], y ;read the argument (which duty cycle to change to)
sta stream_vol_duty, x ;store it.
rts
Done! Now we have the subroutine, but we still need to add it to our jump table:
sound_opcodes:
.word se_op_endsound ;this should be $A0
.word se_op_loop ;this should be $A1
.word se_op_change_ve ;this should be $A2
.word se_op_duty ;this should be $A3
;etc, 1 entry per subroutine
;these are aliases to use in the sound data.
endsound = $A0
loop = $A1
volume_envelope = $A2
duty = $A3
And it's ready to use:
song0_square1:
;intro, don't loop this part
.byte quarter
.byte C4, C4, C4, C4
.loop_point: ;this is where we will loop back to.
.byte duty, $B0 ;change the duty cycle
.byte volume_envelope, ve_blip_echo ;change the volume envelope
.byte eighth ;set note length to eighth notes
.byte C5, E5, G5, C6, E6, G6 ;play some notes
.byte duty, $30 ;change the duty cycle
.byte volume_envelope, ve_short_staccato ;change volume envelope
.byte C5, Eb5, G5, C6, Eb6, half, G6 ;play some eighth notes and a half note
.byte loop ;loop to .loop_point
.word .loop_point
Readability
sound_engine.asm is getting pretty bulky with all these subroutines. It will only get bigger as we add more opcodes. It's nice to have all of our opcodes together in one place, but it's annoying to have to scroll around to find them. So let's pull all of our opcodes into their own file: sound_opcodes.asm. Then, at the bottom of sound_engine.asm, we can .include it:
.include "sound_opcodes.asm" ;our opcode subroutines, jump table and aliases
.include "note_table.i" ;period lookup table for notes
.include "note_length_table.i"
.include "vol_envelopes.i"
.include "song0.i" ;holds the data for song 0 (header and data streams)
.include "song1.i" ;holds the data for song 1
.include "song2.i"
.include "song3.i"
.include "song4.i"
.include "song5.i"
.include "song6.i" ;oooh.. new song!
I gave it the extension .asm because it contains code as well as data, and I like to be able to tell at a glance what files have what in them. Now whenever we want to add new opcodes, or tweak old ones, we have them nice and compact in their own file.
Updating Sound Data
Whenever we add new things to our sound engine, we have to think about how it will affect our old sound data. This week we added opcodes, which will change our songs and sound effects terminate. Before we were terminating them with $FF. This won't work anymore because $FF doesn't do anything. For songs, we should terminate with "loop" followed by an address to loop to. With sound effects we should terminate with the opcode "endsound". See the included songs and sound effects for examples.
RTS Tables
We talked about jump tables and indirect jumping this week. Another method for doing the same thing involves something called an RTS table and the RTS Trick. I won't cover it in these tutorials, but if you are curious to know how this works you can read this nesdev wiki article I wrote about the RTS Trick.
Putting It All Together
Download and unzip the opcodes.zip sample files. Make sure the following files are in the same folder as NESASM3:
opcodes.asm
sound_engine.asm
sound_opcodes.asm
opcodes.chr
note_table.i
note_length_table.i
vol_envelopes.i
song0.i
song1.i
song2.i
song3.i
song4.i
song5.i
song6.i
opcodes.bat
Double click opcodes.bat. That will run NESASM3 and should produce the opcodes.nes file. Run that NES file in FCEUXD SP.
Use the controller to select songs and play them. Controls are as follows:
Up: Play
Down: Stop
Right: Next Song/SFX
Left: Previous Song/SFX
Song0 is a silence song. Not selectable.
Song1 is a boss song from The Guardian Legend. Now it loops!
Song2 is the same short sound effect from last week. Terminated with endsound.
Song3 is a song from Dragon Warrior. Now it loops!
Song4 is the same song4 as last week, but now it loops!
Song5 is a short sound effect, terminated with the endsound opcode.
Song6 should be familiar to readers of this forum. Do you recognize it? It utilizes opcodes for changing duty cycles and volume envelopes. Plus it loops!
Try adding your own songs and sound effects in. Try to add your own opcodes too. Here's some ideas for opcodes:
1. Trigger a sound effect mid-song
2. Implement duty cycle envelopes (similar to volume envelopes). Then make an opcode that allows you to change it.
3. Finite loops
Next Week: more opcode fun. Finite Loops, Changing Keys and Autom... .
-------------------------
MetalSlime runs away
My nesdev blog: http://tummaigames.com/blog...
Edited: 11/30/2009 at 02:20 AM by MetalSlime
Nov 10, 2009 at 1:42:51 PM
udisi (88)
< King Solomon >
Posts: 3261 - Joined: 11/15/2006
United States
Profile
excellent...muhahahaha.
btw, song6 would be battlekid minus noise channel
Edited: 11/10/2009 at 10:14 PM by udisi
Nov 13, 2009 at 8:55:35 AM
Mario's Right Nut (350)
(Cunt Punch) < Bowser >
Posts: 6574 - Joined: 11/21/2008
Texas
Profile

Wow, dude, this is awesome!

I like the Jump Tables. I was wondering if you could do that, for like the interference in various rooms, but I hadn't got to it yet. Cool beans.

-------------------------

This is my shiny thing, and if you try to take it off me, I may have to eat you.

Check out my dev blog.

Edited: 11/13/2009 at 09:03 AM by Mario's Right Nut
Jan 10, 2015 at 5:22:17 PM
SUBSCRIBER
zi (70)
(T Rags) < Kraid Killer >
Posts: 2314 - Joined: 06/02/2008
New York
Profile
question from five years later: how can I change the duty to the noise channel?
In famitracker you can change the noise channel duty (00 or 010). I tried changing the duty via opcode, either by increasing the duty increment by one or by 16, nothing besides a different note (instead of a C noise it's an F noise, but increasing by all sorts of odd numbers doesn't give me the sound I get from a duty change in Fami)
Is there a way to do this or am I forcing a famitracker feature on an in game music engine?
Jan 10, 2015 at 7:04:48 PM
Roth (66)
(Rob Bryant) < Lolo Lord >
Posts: 1718 - Joined: 09/14/2006
Illinois
Profile
I'm not sure about this specific engine, but are you referring to making the more chime-like sound in the noise channel? If so, that can be made by setting bit 7 of $400e:
http://wiki.nesdev.com/w/index.ph...
-------------------------
http://slydogstudios.org...
Jan 10, 2015 at 10:27:10 PM
SUBSCRIBER
zi (70)
(T Rags) < Kraid Killer >
Posts: 2314 - Joined: 06/02/2008
New York
Profile
I am, and I'm going to task the programmer to make that a pretty lil' opcode so I can call it and not worry about ever typing lda. thanks rob!

Login
User Name:
Password
Stay logged in
Forget your login? Join NintendoAge!